Conversation
🦋 Changeset detectedLatest commit: 0975d4d The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
7de9bc2 to
1d0aa0f
Compare
WalkthroughAdds a new authenticated GET /wallet endpoint that looks up a user's credential and active/frozen card, calls Panda API for processor-level card details, and returns a processor card ID and time-based secret; includes tests and a changeset declaring a patch release. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant API as GET /wallet
participant DB as Database
participant Panda as Panda API
Client->>API: GET /wallet (credentialId cookie)
API->>DB: Load credential (pandaId, first ACTIVE|FROZEN card)
alt no credential
DB-->>API: not found
API-->>Client: 500 { code: "no credential" }
else credential missing pandaId
DB-->>API: credential (no pandaId)
API-->>Client: 403 { code: "no panda" }
else no eligible card
DB-->>API: credential (no card)
API-->>Client: 404 { code: "no card" }
else credential + card found
DB-->>API: credential + cardId
API->>Panda: GET /issuing/cards/{cardId}/processorDetails
alt processor details found
Panda-->>API: { processorCardId, timeBasedSecret }
API-->>Client: 200 { cardId, cardSecret }
else processor 404
Panda-->>API: 404
API-->>Client: 404 { code: "no card" }
else other error
Panda-->>API: 5xx
API-->>Client: 500 (error)
end
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ All tests passed. |
af7c987 to
9495bf1
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 88412791-ba8b-402b-8896-65830bb065b6
📒 Files selected for processing (4)
.changeset/chilly-suns-dress.mdserver/api/card.tsserver/test/api/card.test.tsserver/utils/panda.ts
| .get( | ||
| "/wallet", | ||
| auth(), | ||
| describeRoute({ | ||
| summary: "Get wallet provisioning credentials", | ||
| tags: ["Card"], | ||
| security: [{ credentialAuth: [] }], | ||
| validateResponse: true, | ||
| responses: { | ||
| 200: { | ||
| description: "Wallet provisioning credentials", | ||
| content: { | ||
| "application/json": { | ||
| schema: resolver(WalletResponse, { errorMode: "ignore" }), | ||
| }, | ||
| }, | ||
| }, | ||
| 403: { | ||
| description: "Forbidden", | ||
| content: { | ||
| "application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) }, | ||
| }, | ||
| }, | ||
| 404: { | ||
| description: "Not found", | ||
| content: { | ||
| "application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) }, | ||
| }, | ||
| }, | ||
| }, | ||
| }), | ||
| async (c) => { | ||
| const { credentialId } = c.req.valid("cookie"); | ||
| const credential = await database.query.credentials.findFirst({ | ||
| where: eq(credentials.id, credentialId), | ||
| columns: { pandaId: true }, | ||
| with: { cards: { columns: { id: true }, where: inArray(cards.status, ["ACTIVE", "FROZEN"]) } }, | ||
| }); | ||
| if (!credential) return c.json({ code: "no credential" }, 500); | ||
| if (!credential.pandaId) return c.json({ code: "no panda" }, 403); | ||
| if (!credential.cards[0]) return c.json({ code: "no card" }, 404); | ||
| try { | ||
| const provisioning = await getProcessorDetails(credential.cards[0].id); | ||
| return c.json({ cardId: provisioning.processorCardId, cardSecret: provisioning.timeBasedSecret } satisfies InferOutput<typeof WalletResponse>, 200); |
There was a problem hiding this comment.
Protect cardSecret the same way card PAN/CVC are protected.
timeBasedSecret is wallet-provisioning material, but this route returns it in clear text behind only the signed auth cookie. The existing GET / flow requires a caller-provided sessionid and encrypts the returned card secrets; /wallet drops that extra protection for an equally sensitive credential. Please reuse the same session-bound envelope here, or another equivalent proof-of-possession mechanism, before returning cardSecret.
| const credential = await database.query.credentials.findFirst({ | ||
| where: eq(credentials.id, credentialId), | ||
| columns: { pandaId: true }, | ||
| with: { cards: { columns: { id: true }, where: inArray(cards.status, ["ACTIVE", "FROZEN"]) } }, | ||
| }); | ||
| if (!credential) return c.json({ code: "no credential" }, 500); | ||
| if (!credential.pandaId) return c.json({ code: "no panda" }, 403); | ||
| if (!credential.cards[0]) return c.json({ code: "no card" }, 404); | ||
| try { | ||
| const provisioning = await getProcessorDetails(credential.cards[0].id); | ||
| return c.json({ cardId: provisioning.processorCardId, cardSecret: provisioning.timeBasedSecret } satisfies InferOutput<typeof WalletResponse>, 200); |
There was a problem hiding this comment.
Choose the wallet card deterministically.
The relation query can return multiple ACTIVE/FROZEN rows, and credential.cards[0] then depends on database order. The schema shown for cards does not enforce a single non-deleted card per credential, so this endpoint can provision the wrong card if stale eligible rows coexist. Add an explicit ordering or otherwise constrain the query to the intended current card before calling Panda.
9495bf1 to
0975d4d
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
server/api/card.ts (2)
613-626:⚠️ Potential issue | 🔴 CriticalProtect wallet provisioning secrets with proof-of-possession.
timeBasedSecretis returned in clear text behind only the auth cookie, while the existingGET /flow requires a caller-providedsessionidand returns encrypted card secrets./walletis exposing equivalent provisioning material with weaker protection.
615-625:⚠️ Potential issue | 🟠 MajorChoose the wallet card deterministically.
This query can return multiple
ACTIVE/FROZENrows, andcredential.cards[0]then depends on database order.server/database/schema.ts:24-54only indexescredential_id, so stale eligible rows can make/walletprovision the wrong card.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 45ecefb7-8b50-4ff9-b2a0-080f60291d69
📒 Files selected for processing (4)
.changeset/chilly-suns-dress.mdserver/api/card.tsserver/test/api/card.test.tsserver/utils/panda.ts
| responses: { | ||
| 200: { | ||
| description: "Wallet provisioning credentials", | ||
| content: { | ||
| "application/json": { | ||
| schema: resolver(WalletResponse, { errorMode: "ignore" }), | ||
| }, | ||
| }, | ||
| }, | ||
| 403: { | ||
| description: "Forbidden", | ||
| content: { | ||
| "application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) }, | ||
| }, | ||
| }, | ||
| 404: { | ||
| description: "Not found", | ||
| content: { | ||
| "application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) }, | ||
| }, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Document the 500 responses this route already emits.
validateResponse is enabled, but the handler returns 500 { code: "no credential" } on Line 620 and can propagate other 5xx failures on Lines 627-629. The OpenAPI block omits 500 entirely, so the contract does not match runtime behavior.
🩹 Proposed contract update
responses: {
description: "Wallet provisioning credentials",
content: {
"application/json": {
schema: resolver(WalletResponse, { errorMode: "ignore" }),
},
},
},
description: "Forbidden",
content: {
"application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) },
},
},
description: "Not found",
content: {
"application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) },
},
},
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": { schema: resolver(object({ code: literal("no credential") }), { errorMode: "ignore" }) },
+ },
+ },
},Also applies to: 620-629
Summary by CodeRabbit
New Features
Tests
Chores